ROI рекламы в приложении Procrastinate Pro+.
# импортируем библиотеки
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from matplotlib import pyplot as plt
import seaborn as sns
from IPython.display import display
# загрузим данные
visits = pd.read_csv('/datasets/visits_info_short.csv')
orders = pd.read_csv('/datasets/orders_info_short.csv')
costs = pd.read_csv('/datasets/costs_info_short.csv')
# посмотрим на датафрейм visits
display(visits.head(5))
| User Id | Region | Device | Channel | Session Start | Session End | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | United States | iPhone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | United States | iPhone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | United States | Mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | United States | Android | TipTop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | United States | Mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
Датафрейм visits состоит из колонок:
User Id уникальный идентификатор пользователя,Region страна пользователя,Device тип устройства пользователя,Channel идентификатор источника перехода,Session Start дата и время начала сессии,Session End дата и время окончания сессии.visits = visits.rename(
columns={'User Id': 'user_id',
'Region' : 'region',
'Device' : 'device',
'Channel' : 'channel',
'Session Start' : 'session_start',
'Session End' : 'session_end'}
)
display(visits.head(5))
| user_id | region | device | channel | session_start | session_end | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | United States | iPhone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | United States | iPhone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | United States | Mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | United States | Android | TipTop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | United States | Mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
Для соблюдения стилистического правила, приведем название колонок к нижнему регистру.
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 309901 non-null int64 1 region 309901 non-null object 2 device 309901 non-null object 3 channel 309901 non-null object 4 session_start 309901 non-null object 5 session_end 309901 non-null object dtypes: int64(1), object(5) memory usage: 14.2+ MB
Датафрейм состоит из 6 колонок и 309901 строк. В колонках с датами необходимо поменять формат на datetime.
visits['session_start'] = pd.to_datetime(visits['session_start'])
visits['session_end'] = pd.to_datetime(visits['session_end'])
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 309901 non-null int64 1 region 309901 non-null object 2 device 309901 non-null object 3 channel 309901 non-null object 4 session_start 309901 non-null datetime64[ns] 5 session_end 309901 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(1), object(3) memory usage: 14.2+ MB
Исправили формат.
visits.isna().sum()
user_id 0 region 0 device 0 channel 0 session_start 0 session_end 0 dtype: int64
Пропусков не обнаружено.
visits.duplicated().sum()
0
Дубликатов не обнаружено.
display(orders.head(5))
| User Id | Event Dt | Revenue | |
|---|---|---|---|
| 0 | 188246423999 | 2019-05-01 23:09:52 | 4.99 |
| 1 | 174361394180 | 2019-05-01 12:24:04 | 4.99 |
| 2 | 529610067795 | 2019-05-01 11:34:04 | 4.99 |
| 3 | 319939546352 | 2019-05-01 15:34:40 | 4.99 |
| 4 | 366000285810 | 2019-05-01 13:59:51 | 4.99 |
Датафрейм orders состоит из:
User Id уникальный идентификатор пользователя,Event Dt дата и время покупки,Revenue сумма заказа.orders = orders.rename(
columns={'User Id': 'user_id',
'Event Dt' : 'event_dt',
'Revenue' : 'revenue'}
)
display(orders.head(5))
| user_id | event_dt | revenue | |
|---|---|---|---|
| 0 | 188246423999 | 2019-05-01 23:09:52 | 4.99 |
| 1 | 174361394180 | 2019-05-01 12:24:04 | 4.99 |
| 2 | 529610067795 | 2019-05-01 11:34:04 | 4.99 |
| 3 | 319939546352 | 2019-05-01 15:34:40 | 4.99 |
| 4 | 366000285810 | 2019-05-01 13:59:51 | 4.99 |
Для соблюдения стилистического правила, приведем название колонок к нижнему регистру.
orders.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 40212 non-null int64 1 event_dt 40212 non-null object 2 revenue 40212 non-null float64 dtypes: float64(1), int64(1), object(1) memory usage: 942.6+ KB
Датафрейм состоит из 3 колонок и 40212 строк. В колонках с датами необходимо поменять формат на datetime.
orders['event_dt'] = pd.to_datetime(orders['event_dt'])
orders.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 40212 non-null int64 1 event_dt 40212 non-null datetime64[ns] 2 revenue 40212 non-null float64 dtypes: datetime64[ns](1), float64(1), int64(1) memory usage: 942.6 KB
Исправили формат.
orders.isna().sum()
user_id 0 event_dt 0 revenue 0 dtype: int64
Пропусков не обнаружено.
orders.duplicated().sum()
0
Дубликатов не обнаружено.
display(costs.head(5))
| dt | Channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.3 |
| 1 | 2019-05-02 | FaceBoom | 78.1 |
| 2 | 2019-05-03 | FaceBoom | 85.8 |
| 3 | 2019-05-04 | FaceBoom | 136.4 |
| 4 | 2019-05-05 | FaceBoom | 122.1 |
Датафрейм costs состоит из:
dt дата проведения рекламной компании,Channel идентификатор рекламного источника,costs расходы на компанию.costs = costs.rename(
columns={'Channel': 'channel'}
)
display(costs.head(5))
| dt | channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.3 |
| 1 | 2019-05-02 | FaceBoom | 78.1 |
| 2 | 2019-05-03 | FaceBoom | 85.8 |
| 3 | 2019-05-04 | FaceBoom | 136.4 |
| 4 | 2019-05-05 | FaceBoom | 122.1 |
Для соблюдения стилистического правила, приведем название колонок к нижнему регистру.
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
Датафрейм состоит из 3 колонок и 1800 строк. В колонках с датами необходимо поменять формат на datetime и округлить до даты.
costs['dt'] = pd.to_datetime(costs['dt']).dt.date
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
costs.isna().sum()
dt 0 channel 0 costs 0 dtype: int64
Пропуски не обнаружены.
costs.duplicated().sum()
0
Дубликаты не обнаружены.
# функция для создания пользовательских профилей
def get_profiles(sessions, orders, ad_costs):
# находим параметры первых посещений
profiles = (
sessions.sort_values(by=['user_id', 'session_start'])
.groupby('user_id')
.agg(
{
'session_start': 'first',
'channel': 'first',
'device': 'first',
'region': 'first',
}
)
.rename(columns={'session_start': 'first_ts'})
.reset_index()
)
# для когортного анализа определяем дату первого посещения
# и первый день месяца, в который это посещение произошло
profiles['dt'] = profiles['first_ts'].dt.date
profiles['month'] = profiles['first_ts'].astype('datetime64[M]')
# добавляем признак платящих пользователей
profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())
# считаем количество уникальных пользователей
# с одинаковыми источником и датой привлечения
new_users = (
profiles.groupby(['dt', 'channel'])
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'unique_users'})
.reset_index()
)
# объединяем траты на рекламу и число привлечённых пользователей
ad_costs = ad_costs.merge(new_users, on=['dt', 'channel'], how='left')
# делим рекламные расходы на число привлечённых пользователей
ad_costs['acquisition_cost'] = ad_costs['costs'] / ad_costs['unique_users']
# добавляем стоимость привлечения в профили
profiles = profiles.merge(
ad_costs[['dt', 'channel', 'acquisition_cost']],
on=['dt', 'channel'],
how='left',
)
# стоимость привлечения органических пользователей равна нулю
profiles['acquisition_cost'] = profiles['acquisition_cost'].fillna(0)
return profiles
# функция для расчёта удержания
def get_retention(
profiles,
sessions,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# добавляем столбец payer в передаваемый dimensions список
dimensions = ['payer'] + dimensions
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# собираем «сырые» данные для расчёта удержания
result_raw = result_raw.merge(
sessions[['user_id', 'session_start']], on='user_id', how='left'
)
result_raw['lifetime'] = (
result_raw['session_start'] - result_raw['first_ts']
).dt.days
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу удержания
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# получаем таблицу динамики удержания
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
# функция для расчёта конверсии
def get_conversion(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# определяем дату и время первой покупки для каждого пользователя
first_purchases = (
purchases.sort_values(by=['user_id', 'event_dt'])
.groupby('user_id')
.agg({'event_dt': 'first'})
.reset_index()
)
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
first_purchases[['user_id', 'event_dt']], on='user_id', how='left'
)
# рассчитываем лайфтайм для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
result = result.fillna(0).cumsum(axis = 1)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# делим каждую «ячейку» в строке на размер когорты
# и получаем conversion rate
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу конверсии
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# для таблицы динамики конверсии убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицу динамики конверсии
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
# функция для расчёта LTV и ROI
def get_ltv(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
purchases[['user_id', 'event_dt', 'revenue']], on='user_id', how='left'
)
# рассчитываем лайфтайм пользователя для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция группировки по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
# строим «треугольную» таблицу выручки
result = df.pivot_table(
index=dims, columns='lifetime', values='revenue', aggfunc='sum'
)
# находим сумму выручки с накоплением
result = result.fillna(0).cumsum(axis=1)
# вычисляем размеры когорт
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
# объединяем размеры когорт и таблицу выручки
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# считаем LTV: делим каждую «ячейку» в строке на размер когорты
result = result.div(result['cohort_size'], axis=0)
# исключаем все лайфтаймы, превышающие горизонт анализа
result = result[['cohort_size'] + list(range(horizon_days))]
# восстанавливаем размеры когорт
result['cohort_size'] = cohort_sizes
# собираем датафрейм с данными пользователей и значениями CAC,
# добавляя параметры из dimensions
cac = df[['user_id', 'acquisition_cost'] + dims].drop_duplicates()
# считаем средний CAC по параметрам из dimensions
cac = (
cac.groupby(dims)
.agg({'acquisition_cost': 'mean'})
.rename(columns={'acquisition_cost': 'cac'})
)
# считаем ROI: делим LTV на CAC
roi = result.div(cac['cac'], axis=0)
# удаляем строки с бесконечным ROI
roi = roi[~roi['cohort_size'].isin([np.inf])]
# восстанавливаем размеры когорт в таблице ROI
roi['cohort_size'] = cohort_sizes
# добавляем CAC в таблицу ROI
roi['cac'] = cac['cac']
# в финальной таблице оставляем размеры когорт, CAC
# и ROI в лайфтаймы, не превышающие горизонт анализа
roi = roi[['cohort_size', 'cac'] + list(range(horizon_days))]
# возвращаем таблицы LTV и ROI
return result, roi
# получаем таблицы LTV и ROI
result_grouped, roi_grouped = group_by_dimensions(
result_raw, dimensions, horizon_days
)
# для таблиц динамики убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицы динамики LTV и ROI
result_in_time, roi_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
return (
result_raw, # сырые данные
result_grouped, # таблица LTV
result_in_time, # таблица динамики LTV
roi_grouped, # таблица ROI
roi_in_time, # таблица динамики ROI
)
# функция для сглаживания фрейма
def filter_data(df, window):
# для каждого столбца применяем скользящее среднее
for column in df.columns.values:
df[column] = df[column].rolling(window).mean()
return df
# функция для визуализации удержания
def plot_retention(retention, retention_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
# функция для визуализации конверсии
def plot_conversion(conversion, conversion_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 5))
# исключаем размеры когорт
conversion = conversion.drop(columns=['cohort_size'])
# в таблице динамики оставляем только нужный лайфтайм
conversion_history = conversion_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# первый график — кривые конверсии
ax1 = plt.subplot(1, 2, 1)
conversion.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Конверсия пользователей')
# второй график — динамика конверсии
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
columns = [
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
name for name in conversion_history.index.names if name not in ['dt']
]
filtered_data = conversion_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
# функция для визуализации LTV и ROI
def plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon, window=7):
# задаём сетку отрисовки графиков
plt.figure(figsize=(20, 10))
# из таблицы ltv исключаем размеры когорт
ltv = ltv.drop(columns=['cohort_size'])
# в таблице динамики ltv оставляем только нужный лайфтайм
ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]
# стоимость привлечения запишем в отдельный фрейм
cac_history = roi_history[['cac']]
# из таблицы roi исключаем размеры когорт и cac
roi = roi.drop(columns=['cohort_size', 'cac'])
# в таблице динамики roi оставляем только нужный лайфтайм
roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
[horizon - 1]
]
# первый график — кривые ltv
ax1 = plt.subplot(2, 3, 1)
ltv.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('LTV')
# второй график — динамика ltv
ax2 = plt.subplot(2, 3, 2, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in ltv_history.index.names if name not in ['dt']]
filtered_data = ltv_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))
# третий график — динамика cac
ax3 = plt.subplot(2, 3, 3, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in cac_history.index.names if name not in ['dt']]
filtered_data = cac_history.pivot_table(
index='dt', columns=columns, values='cac', aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title('Динамика стоимости привлечения пользователей')
# четвёртый график — кривые roi
ax4 = plt.subplot(2, 3, 4)
roi.T.plot(grid=True, ax=ax4)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('ROI')
# пятый график — динамика roi
ax5 = plt.subplot(2, 3, 5, sharey=ax4)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in roi_history.index.names if name not in ['dt']]
filtered_data = roi_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax5)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.xlabel('Дата привлечения')
plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
profiles = get_profiles(visits, orders, costs)
display(profiles.head(5))
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | FaceBoom | Mac | United States | 2019-05-07 | 2019-05-01 | True | 1.088172 |
| 1 | 4919697 | 2019-07-09 12:46:07 | FaceBoom | iPhone | United States | 2019-07-09 | 2019-07-01 | False | 1.107237 |
| 2 | 6085896 | 2019-10-01 09:58:33 | organic | iPhone | France | 2019-10-01 | 2019-10-01 | False | 0.000000 |
| 3 | 22593348 | 2019-08-22 21:35:48 | AdNonSense | PC | Germany | 2019-08-22 | 2019-08-01 | False | 0.988235 |
| 4 | 31989216 | 2019-10-02 00:07:44 | YRabbit | iPhone | United States | 2019-10-02 | 2019-10-01 | False | 0.230769 |
Используя функцию get_profiles составили таблицу профилей пользователей приложения.
min_analysis_date = profiles['dt'].min()
max_analysis_date = profiles['dt'].max()
print(min_analysis_date)
print(max_analysis_date)
2019-05-01 2019-10-27
Отпеределили минимальную (01 мая 2019) и максимальную (27 октября 2019) даты привлечения пользователей. Они соответствуют интервалу указанному в задаче.
user_count = profiles['user_id'].nunique()
print('Общее количество уникальных пользователей {} человек'.format(user_count))
Общее количество уникальных пользователей 150008 человек
# напишем функцию для распределения по признаку
def share (n, i):
user_count = 150008
share = n.pivot_table(index=i, columns='payer',\
values='user_id', aggfunc='count') # выберем данные
share.columns = ['not_payer', 'payer'] # переименуем колонки
share['total'] = share['not_payer'] + share['payer'] # добавим колонку total
share['share_total'] = share['total']/user_count*100 # определим % от общего
share['share_payer'] = share['payer']/share['total']*100 # определим % платильщиков
share = share.sort_values(by='total', ascending=False) # отсортируем по убыванию
share = share.reset_index()# добавим индекс
return share
Напишем функцию для расчета распределения по таблице profiles.
# используя функцию share посмотрим на распределение по регионам
region_share = share(profiles, profiles['region'])
region_share
| region | not_payer | payer | total | share_total | share_payer | |
|---|---|---|---|---|---|---|
| 0 | United States | 93100 | 6902 | 100002 | 66.664445 | 6.901862 |
| 1 | UK | 16875 | 700 | 17575 | 11.716042 | 3.982930 |
| 2 | France | 16787 | 663 | 17450 | 11.632713 | 3.799427 |
| 3 | Germany | 14365 | 616 | 14981 | 9.986801 | 4.111875 |
Пользователи распределились по 4 регионам следующим образом:
# сгруппируем данные для графика
profiles_user = profiles.groupby(['region', 'payer'],\
as_index = False)[['user_id']].count()
profiles_user = profiles_user.sort_values(by='user_id', ascending=False)
# выберем стиль графика
sns.set_style('white')
sns.set_theme(style="ticks", font_scale=2.0)
# назначаем размер графика
plt.figure(figsize=(15, 8))
# строим столбчатый график средствами seaborn
sns.barplot(x='user_id', y='region', data=profiles_user, hue='payer')
# формируем заголовок графика и подписи осей средствами matplotlib
plt.title('Распределение пользователей по регионам')
plt.xlabel('Количество пользователей')
plt.ylabel('Регионы')
# выбираем положение легенды и указываем размер шрифта
plt.legend(loc='lower right', fontsize=25)
# отображаем график на экране
plt.show()
Построим график для наглядной визуализации наших наблюдений. К сожалению показатель платильщиков шлишком мал, чтоб его значения считывались из визуализации.
# используя функцию share посмотрим на распределение по устройствам
device_share = share(profiles, profiles['device'])
device_share
| device | not_payer | payer | total | share_total | share_payer | |
|---|---|---|---|---|---|---|
| 0 | iPhone | 51097 | 3382 | 54479 | 36.317396 | 6.207897 |
| 1 | Android | 32982 | 2050 | 35032 | 23.353421 | 5.851793 |
| 2 | PC | 28918 | 1537 | 30455 | 20.302251 | 5.046790 |
| 3 | Mac | 28130 | 1912 | 30042 | 20.026932 | 6.364423 |
Пользователи распределились по 4 устройствам следующим образом:
# сгруппируем данные для графика
profiles_user = profiles.groupby(['device', 'payer'],\
as_index = False)[['user_id']].count()
profiles_user = profiles_user.sort_values(by='user_id', ascending=False)
# выберем стиль графика
sns.set_style('white')
sns.set_theme(style="ticks", font_scale=2.0)
# назначаем размер графика
plt.figure(figsize=(15, 8))
# строим столбчатый график средствами seaborn
sns.barplot(x='user_id', y='device', data=profiles_user, hue='payer')
# формируем заголовок графика и подписи осей средствами matplotlib
plt.title('Распределение пользователей по устройствам')
plt.xlabel('Количество пользователей')
plt.ylabel('Устройства')
# выбираем положение легенды и указываем размер шрифта
plt.legend(loc='lower right', fontsize=25)
# отображаем график на экране
plt.show()
Построим график для визуализации наших наблюдений. IPhone отчетливо выделяется, как по общему количеству, так и количеству платильщиков.
# используя функцию share посмотрим на распределение по каналам привлечения
channel_share = share(profiles, profiles['channel'])
channel_share
| channel | not_payer | payer | total | share_total | share_payer | |
|---|---|---|---|---|---|---|
| 0 | organic | 55279 | 1160 | 56439 | 37.623993 | 2.055316 |
| 1 | FaceBoom | 25587 | 3557 | 29144 | 19.428297 | 12.204914 |
| 2 | TipTop | 17683 | 1878 | 19561 | 13.039971 | 9.600736 |
| 3 | OppleCreativeMedia | 8372 | 233 | 8605 | 5.736361 | 2.707728 |
| 4 | LeapBob | 8291 | 262 | 8553 | 5.701696 | 3.063253 |
| 5 | WahooNetBanner | 8100 | 453 | 8553 | 5.701696 | 5.296387 |
| 6 | RocketSuperAds | 4096 | 352 | 4448 | 2.965175 | 7.913669 |
| 7 | MediaTornado | 4208 | 156 | 4364 | 2.909178 | 3.574702 |
| 8 | YRabbit | 4147 | 165 | 4312 | 2.874513 | 3.826531 |
| 9 | AdNonSense | 3440 | 440 | 3880 | 2.586529 | 11.340206 |
| 10 | lambdaMediaAds | 1924 | 225 | 2149 | 1.432590 | 10.469986 |
Пользователи распределились по 11 каналам привлечения следующим образом:
# сгруппируем данные для графика
profiles_user = profiles.groupby(['channel', 'payer'],\
as_index = False)[['user_id']].count()
profiles_user = profiles_user.sort_values(by='user_id', ascending=False)
# выберем стиль графика
sns.set_style('white')
sns.set_theme(style="ticks", font_scale=2.0)
# назначаем размер графика
plt.figure(figsize=(15, 8))
# строим столбчатый график средствами seaborn
sns.barplot(x='user_id', y='channel', data=profiles_user, hue='payer')
# формируем заголовок графика и подписи осей средствами matplotlib
plt.title('Распределение пользователей по каналам привлечения')
plt.xlabel('Количество пользователей')
plt.ylabel('Канал привлечения')
# выбираем положение легенды и указываем размер шрифта
plt.legend(loc='lower right', fontsize=25)
# отображаем график на экране
plt.show()
Построим график для визуализации наших наблюдений. Количество пользователей пришедших без рекламы (organic) заметно больше остальных, но платильщиков пришедших от FaceBoom и TipTop больше.
country_channel = profiles.pivot_table(
index='channel', columns='region', values='user_id', aggfunc='nunique')
country_channel
| region | France | Germany | UK | United States |
|---|---|---|---|---|
| channel | ||||
| AdNonSense | 1366.0 | 1219.0 | 1295.0 | NaN |
| FaceBoom | NaN | NaN | NaN | 29144.0 |
| LeapBob | 2988.0 | 2519.0 | 3046.0 | NaN |
| MediaTornado | NaN | NaN | NaN | 4364.0 |
| OppleCreativeMedia | 2991.0 | 2562.0 | 3052.0 | NaN |
| RocketSuperAds | NaN | NaN | NaN | 4448.0 |
| TipTop | NaN | NaN | NaN | 19561.0 |
| WahooNetBanner | 2971.0 | 2579.0 | 3003.0 | NaN |
| YRabbit | NaN | NaN | NaN | 4312.0 |
| lambdaMediaAds | 742.0 | 649.0 | 758.0 | NaN |
| organic | 6392.0 | 5453.0 | 6421.0 | 38173.0 |
Дополнительно посмотрим на распределение пользователей по странам и каналам привлечения. Видим, что каналы TipTop, FaceBoom, MediaTornado, YRabbit и RocketSuperAds работают только в United States. Остальные каналы относятся к Европе.
marketing_expenses = costs['costs'].sum()
print('Суммарные расходы на рекламу составили {} '. format(marketing_expenses))
Суммарные расходы на рекламу составили 105497.30000000002
marketing_expenses_channel = profiles.pivot_table(
index='channel', values='acquisition_cost', aggfunc='sum')
marketing_expenses_channel['share'] = marketing_expenses_channel['acquisition_cost']/marketing_expenses*100
marketing_expenses_channel = marketing_expenses_channel.sort_values(by='acquisition_cost', ascending=False)
marketing_expenses_channel = marketing_expenses_channel.reset_index()
marketing_expenses_channel
| channel | acquisition_cost | share | |
|---|---|---|---|
| 0 | TipTop | 54751.30 | 51.898295 |
| 1 | FaceBoom | 32445.60 | 30.754910 |
| 2 | WahooNetBanner | 5151.00 | 4.882589 |
| 3 | AdNonSense | 3911.25 | 3.707441 |
| 4 | OppleCreativeMedia | 2151.25 | 2.039152 |
| 5 | RocketSuperAds | 1833.00 | 1.737485 |
| 6 | LeapBob | 1797.60 | 1.703930 |
| 7 | lambdaMediaAds | 1557.60 | 1.476436 |
| 8 | MediaTornado | 954.48 | 0.904744 |
| 9 | YRabbit | 944.22 | 0.895018 |
| 10 | organic | 0.00 | 0.000000 |
Расходы по каналам привлечения распределились следующим образом:
sns.set_style('white')
sns.set_theme(style="ticks", font_scale=2.)
plt.figure(figsize=(15, 8))
sns.barplot(x='acquisition_cost', y='channel', data=marketing_expenses_channel)
plt.title('Распределение расходов по каналам привлечения')
plt.xlabel('Сумма расходов')
plt.ylabel('Канал привлечения')
plt.show()
Визуализируем наши наблюдения на графике.
# Добавим в прифили новые колонки в формате недели и месяца
profiles['week'] = profiles['dt'].astype('datetime64[W]')
profiles['month'] = profiles['dt'].astype('datetime64[M]')
profiles.head(3)
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | week | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | FaceBoom | Mac | United States | 2019-05-07 | 2019-05-01 | True | 1.088172 | 2019-05-02 |
| 1 | 4919697 | 2019-07-09 12:46:07 | FaceBoom | iPhone | United States | 2019-07-09 | 2019-07-01 | False | 1.107237 | 2019-07-04 |
| 2 | 6085896 | 2019-10-01 09:58:33 | organic | iPhone | France | 2019-10-01 | 2019-10-01 | False | 0.000000 | 2019-09-26 |
# Построим график динамики расходов на привлечение (по неделям и месяцам) для каждого канала
plt.figure(figsize=(24, 8)) # зададим размер графика
sns.set_theme(style="ticks", font_scale=1.25) # добавим параметр размера подписи
# выберем данные за неделю и сформируем в первом графике
profiles.query('channel != "organic"').pivot_table(index='week',
columns='channel',values='acquisition_cost', aggfunc='sum').plot.area(
grid=True, ax=plt.subplot(1, 2, 1), cmap='tab20c')
plt.title('Динамика изменений расходов по каналам')
plt.xlabel('Неделя')
plt.ylabel('Расходы')
plt.legend(loc='upper left')
# выберем данные за месяц и сформируем во втором графике
profiles.query('channel != "organic"').pivot_table(index='month',
columns='channel',values='acquisition_cost', aggfunc='sum').plot.area(
grid=True, ax=plt.subplot(1, 2, 2), cmap='tab20c')
plt.title('Динамика изменений расходов по каналам')
plt.xlabel('Месяц')
plt.ylabel('Расходы')
plt.legend(loc='upper left')
plt.show()
На графике динамики расходов по каналам привлечения видим, что в разрезе недельного показателя, лидер TipTop показывает высокую валатильность, чуть большую стабильность показывает канал FaceBoom, у остальных расходы больее стабильные.
cac_chennel = profiles.pivot_table(
index='channel', values='acquisition_cost',
aggfunc='mean').sort_values(by='acquisition_cost', ascending=False)
cac_chennel
| acquisition_cost | |
|---|---|
| channel | |
| TipTop | 2.799003 |
| FaceBoom | 1.113286 |
| AdNonSense | 1.008054 |
| lambdaMediaAds | 0.724802 |
| WahooNetBanner | 0.602245 |
| RocketSuperAds | 0.412095 |
| OppleCreativeMedia | 0.250000 |
| YRabbit | 0.218975 |
| MediaTornado | 0.218717 |
| LeapBob | 0.210172 |
| organic | 0.000000 |
По показателю САС мы видим, что стоимость привлечения у тройки лидеров, выше 1, а у лидера TipTop цена значительно выше и составляет 2,79.
profiles = profiles[profiles['channel'] != 'organic']
В дальнейшем анализе нас будут интересовать только пользователи привлеченные по платным каналам, поэтому organic исключим из выборки.
involved_user = profiles['user_id'].nunique()
cac_general = marketing_expenses/involved_user
cac_general
1.127481323942759
Посчитали общий показатель САС, он составляет 1.12, что говорит об общей убыточности.
# зададим момент анализа
observation_date = datetime(2019, 11, 1).date()
# зададим горизонт анализа
horizon_days = 14
# считаем LTV и ROI
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days
)
# строим графики
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
Из графиков можно сделать вывод:
# смотрим окупаемость с разбивкой по устройствам
dimensions = ['device']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
По данным графиков окупаемости по устройствам, можно сделать вывод:
# смотрим окупаемость с разбивкой по странам
dimensions = ['region']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
На графиках окупаемости по странам можем наблюдать:
# смотрим окупаемость с разбивкой по источникам привлечения
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
На графиках окупаемости по каналам привлечения можем наблюдать:
# смотрим конверсию с разбивкой по устройствам
dimensions = ['device']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По конверсии можем наблюдать:
# смотрим удержание с разбивкой по устройствам
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
По графику удержания можно сделать вывод:
# смотрим конверсию с разбивкой по каналам привлечения
dimensions = ['channel']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По конверсии можем наблюдать:
# смотрим удержание с разбивкой по каналам привлечения
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
По графику удержания можно сделать вывод:
# смотрим конверсию с разбивкой по регионам
dimensions = ['region']
conversion_raw, conversion_grouped, conversion_history = get_conversion(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_conversion(conversion_grouped, conversion_history, horizon_days)
По конверсии можем наблюдать:
# смотрим удержание с разбивкой по регионам
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
По графику удержания можно сделать вывод:
На основании нашего исследования, мы выявили каналы которые влияют на рентабельность. Проверим наши выводы исключив таксичные каналы TipTop, FaceBoom и AdNonSense из выборки и посмотрим на показатели ROI.
profiles = profiles[profiles['channel'] != 'TipTop']
profiles = profiles[profiles['channel'] != 'FaceBoom']
profiles = profiles[profiles['channel'] != 'AdNonSense']
# считаем LTV и ROI
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days
)
# строим графики
plot_ltv_roi(ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days)
# смотрим окупаемость с разбивкой по источникам привлечения
dimensions = ['channel']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)
# смотрим удержание с разбивкой по регионам
dimensions = ['region']
retention_raw, retention_grouped, retention_history = get_retention(
profiles, visits, observation_date, horizon_days, dimensions=dimensions
)
plot_retention(retention_grouped, retention_history, horizon_days)
# смотрим окупаемость с разбивкой по странам
dimensions = ['region']
ltv_raw, ltv_grouped, ltv_history, roi_grouped, roi_history = get_ltv(
profiles, orders, observation_date, horizon_days, dimensions=dimensions
)
plot_ltv_roi(
ltv_grouped, ltv_history, roi_grouped, roi_history, horizon_days, window=14
)